Skip to content

feat(refit): add ModelExpress Megatron-to-vLLM reshard path - #3632

Open
KavinKrishnan wants to merge 25 commits into
NVIDIA-NeMo:mainfrom
KavinKrishnan:kavink/upstream-mx-megatron-publisher
Open

feat(refit): add ModelExpress Megatron-to-vLLM reshard path#3632
KavinKrishnan wants to merge 25 commits into
NVIDIA-NeMo:mainfrom
KavinKrishnan:kavink/upstream-mx-megatron-publisher

Conversation

@KavinKrishnan

@KavinKrishnan KavinKrishnan commented Aug 13, 2026

Copy link
Copy Markdown

What this PR does

This PR adds the complete NeMo-RL side of the ModelExpress mx_reshard refit path:

Each part has a narrow job in making trainer-to-generator refits usable from NeMo-RL:

  • Describe what every Megatron rank owns. Megatron stores model weights already split across TP, EP, and ETP ranks. The publisher records each rank's real piece and its position in the full tensor, so ModelExpress can read it directly instead of first gathering a complete model on every trainer.
  • Translate Megatron names into the names vLLM expects. The trainer and generator use different names for the same weights, and fused tensors may map to several HF tensors. Megatron-Bridge supplies that relationship; this adapter preserves the required Q/K/V and gate/up order and fails when a name is ambiguous.
  • Connect ModelExpress to NeMo-RL's normal refit lifecycle. The WeightSynchronizer now starts publishers and receivers, waits for all required ranks, runs the transfer, commits the new version, and shuts both sides down in a safe order. Callers do not need a separate ModelExpress-only control path.
  • Prevent partial models from becoming active. A refit proceeds only after every expected publisher is ready, and one receiver failure prevents the version from being committed. This avoids serving a model assembled from a mixture of old and new weights.
  • Make correctness and delays visible. Parameter verification catches transfers that report success but install unchanged or wrong bytes. Separate stage records show whether time was spent waiting for publishers, moving bytes, or installing tensors, which keeps performance investigations from guessing.
  • Provide a functional GRPO gate that cannot pass by accident. The test checks that the recipe launches, the model actually changes, and trainer/generator probabilities remain aligned. Portable recipes and analysis tools keep the same checks reproducible without upstreaming private cluster names or images.
  • Publish global QKV geometry from each live layer. Nemotron-style layers can have fewer KV heads than TP ranks, and different layers may use different attention shapes. Reading each owning linear_qkv module avoids 2 // 8 = 0 and prevents one root-model setting from being incorrectly reused for every layer.

ModelExpress dependency

This path needs ModelExpress main at or after f4660d2. ModelExpress #635 carried the transport work and the global QKV interval alias builder this PR's QKV contract requires, and it merged on 2026-08-19, so no ModelExpress branch is needed any more. It was squash-merged, so pin by commit rather than by ancestry.

Neither half is useful alone: NeMo-RL publishes the geometry, ModelExpress maps the intervals. ModelExpress #482 does not work — it lacks VllmReshardReceiver (modelexpress.engines.vllm.refit.receiver), which is what this PR imports.

Enable the path with policy.generation.refit_transport: mx_reshard. This is not the legacy cluster.weight_sync.method: mx path; the two are unrelated.

Architecture

flowchart LR
    M[Live Megatron layer] --> G[Per-layer Q/KV geometry resolver]
    G --> C[Parameter classifier]
    C --> B[Megatron-Bridge name map]
    B --> P[ModelExpress publisher]
    P --> Q[Publisher quorum]
    Q --> R[vLLM reshard receiver]
    R --> I[Install and verify]
    I --> V[Commit one version]

    W[WeightSynchronizer] --> P
    W --> Q
    W --> R
    W --> V
    W --> S[Ordered receiver/publisher shutdown]
Loading

For Q=64, KV=2 and trainer TP8, NeMo-RL now publishes global num_heads, num_kv_heads, head_dim, and qkv_interleave from each live linear_qkv.config. It does not publish num_kv_heads_local=0, and it does not pretend every rank owns a replicated KV head. ModelExpress receives each rank's real fused-row range and performs the interval mapping.

Why per-layer geometry matters

Nemotron-family models may use different attention geometry in different layers. Reading one root model config would stamp the same Q/KV shape onto every QKV tensor. The resolver therefore walks to the owning live linear_qkv module first and uses the root values only as a compatibility fallback. The chosen geometry must match the tensor's global fused-row count or publication fails closed.

Performance measured on real model tensors

These are three different experiments, not interchangeable rows from one run:

  • Dense performance A/B — Qwen3-4B-Thinking-2507. The Megatron trainer uses 16 GPUs as TP2 x PP1 x DP8. The generator uses the other 16 GPUs as four independent vLLM replicas, each TP4. Every model weight therefore exists on eight trainer DP replicas, while each generation replica needs one TP4-sharded copy. This is the setup that measures replica deduplication: the baseline reads all eight byte-identical trainer replicas; the new path identifies them by geometry and digest and reads one.
  • MoE coverage and timing — Qwen3-30B-A3B-Instruct-2507. This is a 48-layer, 128-expert MoE model. The trainer uses 16 GPUs as TP2 x PP1 x EP4 x ETP1 x DP2; generation again uses 16 GPUs as four vLLM TP4 replicas. This setup exercises grouped-expert naming and fused expert capture, then reshards TP2/EP4 trainer ownership into TP4 generator ownership. The model has Q=32, KV=4, head_dim=128, so it exercises the global QKV path but not KV heads below TP.
  • KV-heads-below-TP correctness — reduced Qwen3, Q=64/KV=2/head_dim=128. One Megatron TP8 model refits one vLLM TP8 model, 8 trainer + 8 generator GPUs. Because each node has four GPUs, both TP8 groups span two nodes. This arm asks whether sparse Q/K/V row ownership is mapped bit-for-bit; it is deliberately not a performance benchmark.

The dense and MoE runs use 8 nodes x 4 NVIDIA GB200 = 32 GPUs, split into 16 non-colocated trainer GPUs and 16 generation GPUs. Nodes are arm64; each has four 189 GB GPUs and four RDMA rails. Weight refit uses NIXL/UCX RDMA. NCCL is used only for Megatron-internal collectives, not as the refit transport. All three arms use BF16.

For performance rows, the methodology is three independent cold starts per arm, then one warm-up plus ten measured warm refits. "Fleet-critical receiver latency" means: for each refit, take the slowest receiver rank because the fleet cannot continue until that rank finishes; then report the distribution of those per-refit maxima. It is not an average-rank latency.

An attribution note, so these numbers are read correctly: the byte and latency reductions below come from the ModelExpress transport changes (replica deduplication, descriptor caching, batched install, fused expert capture). This PR is the NeMo-RL half that makes that transport reachable from GRPO, enforces the version/quorum ordering it needs, and supplies the telemetry that produced every figure here. The rows are what a NeMo-RL user actually gets end to end, not a claim that this diff alone caused them.

Dense Qwen3-4B: replica-deduplication A/B

This table belongs only to the dense setup above: Qwen3-4B, Megatron TP2/DP8 on 16 GPUs -> four vLLM TP4 replicas on 16 GPUs. Same model checkpoint, prompts, NeMo-RL tree, nodes and placement in both arms; only ModelExpress differs (9abaf2dd, the transport's parent, versus 3ddb9b16, the six-change candidate).

Dense Qwen3-4B, ten measured refits Parent 9abaf2dd Candidate 3ddb9b16 Effect
Wire bytes per rank per refit 16.09 GB 2.01 GB 8.00x less
Read descriptors per rank 1,478,032 184,754 8.00x fewer
Fleet-critical receiver latency, median 4.452 s 0.587 s 7.58x faster
Fleet-critical receiver latency, p95 4.818 s 0.603 s 7.99x
Framework transfer_and_update_weights, median 6.60 s 1.13 s 5.84x faster
Per-rank wire throughput, median 63.98 Gbps 32.5 Gbps

Comparing median-of-three cold starts against median-of-three, the receiver speedup is 7.57x (4.526 s -> 0.598 s). Byte counts were bit-identical across all three cold starts in each arm, with fallback and full_pull_sources at 0 throughout.

Please read the throughput row carefully and do not quote it as a regression. Per-rank throughput goes down while wall clock improves 7.6x. The baseline sustains a higher rate precisely because it moves 8x more data with eight concurrent sources per shard, which amortises per-descriptor overhead well; the new path finishes so much sooner that fixed costs occupy a larger share of a much shorter transfer. The win is not moving redundant bytes at all — the trainer runs DP8, so every weight exists on eight data-parallel replicas and the receiver was re-reading the same bytes — rather than driving the fabric harder.

The framework-visible 5.84x is the honest user-facing number, and it is smaller than the receiver-side ratio because it also carries publisher-side preparation and Ray call overhead.

MoE Qwen3-30B-A3B: expert coverage

This table belongs only to the MoE setup above: Qwen3-30B-A3B, Megatron TP2/EP4/DP2 on 16 GPUs -> four vLLM TP4 replicas on 16 GPUs. "Before" is the same model and topology without fused grouped-expert capture; "after" is the current reshard path. Before the fix, MX deliberately failed closed at ~5% coverage rather than activating a partial or stale model. The 18,432 unsupported entries are the model's grouped expert tensors, not arbitrary transfer failures.

MoE Qwen3-30B-A3B coverage Before fused expert capture Current path
coverage_pct 5.1667 100.0
copies_captured 435 18,867
unsupported 18,432 0
params_installed / engine_params 339 / 435 435 / 435
params_never_written 96 0
dest_bytes / engine_bytes 0.79 / 15.29 GB 15.29 / 15.29 GB
fallback 18,432 0

Sustained across 11 consecutive refits, then re-confirmed over 12 refits, and re-confirmed again against merged ModelExpress main.

Per receiver rank per refit on the MoE arm: planned_wire_bytes 29,780,766,720 against engine_bytes 15,285,252,096, so 14,495,514,624 extra — a 1.948x amplification inherent to resharding TP2/EP4 into TP4, with 12,693,891 exact descriptors and fallback 0.

MoE Qwen3-30B-A3B: where one 10.25 s refit goes

This is a separate warm timing decomposition of the same Qwen3-30B-A3B topology, not the dense A/B above. It is the least flattering number and the most useful one for capacity planning. It is also only visible because of the stage split added in b236bc35d; before that, ModelExpress telemetry covered the install alone and reported 1.9 s of a 10.25 s refit, which looked healthy.

A warm MoE refit (Qwen3-30B-A3B) costs 10.25 s end to end:

Phase Time Share
mx_reshard_publish 3.25 s 32%
mx_reshard_receive -> discover_trainers 5.20 s 51%
mx_reshard_receive -> update_weights (transfer + install) 1.92 s 19%

Each phase is reported from its own median, so the rounded times and shares must not be added as if they were three spans from one trace; that is why the displayed shares total 102%. The interpretation is still stable: metadata discovery is the dominant term, publisher preparation is second, and transfer plus install is smallest.

The largest single cost in a refit is a metadata quorum check, at 2.7x the actual weight transfer. Refit is 45.6% of step time, so discover_trainers alone is roughly 22% of every training step. It re-fetches and re-parses 78,760 tensor entries across 16 trainer sources on every refit when the only thing that changed is publisher_step. The cost scales with source count rather than bytes, which is why a 30B MoE with 18,432 expert tensors exposed it and a dense 4B (~6,400 entries, ~0.4 s) did not.

This is a known, unfixed ceiling that needs an upstream ModelExpress protocol change to carry publisher_step in the list_sources record, making the check O(1) and model-size independent. Projected gain is ~10.3 s -> ~5.1 s per refit, about -22% step time.

Client-side mitigations are exhausted and measured, so please do not ask for them without new information. Fetching the per-source metadata concurrently made things 49% worse (total discover cost 5.41 s -> 8.04 s, the fetch term alone 4.02 s -> 7.56 s): the client looks idle while waiting, but the resource it waits on is the shared metadata server, so concurrency only adds contention. Skipping the shard-table rebuild did what it claimed locally, 0.79 s -> 0.20 s of parse time, but produced no end-to-end gain because the fetch dominates. eae031f60 keeps the cheap version-stamp path, and the serial fetch is pinned by a test that a concurrent implementation fails.

MoE Qwen3-30B-A3B: run-to-run variance

This is receiver-side transfer/install timing for the same MoE topology across three independently created clusters. It is not the 10.25 s framework refit total above: it isolates the receiver critical path so the source of run-to-run spread can be attributed. Fleet-critical medians were 1.845 s, 1.424 s and 1.870 s: a 1.31x across-run spread. attribute_variance.py localises it:

Stage cold 1 cold 2 cold 3 spread share of critical path
wire_fused_s 1.5343 1.1801 1.6167 1.37x 94.2%
install_s 0.0945 0.0943 0.0945 1.00x 5.8%

All of the variance is the wire; the receiver-side install is deterministic to four decimal places and has no headroom left. The straggler rank moves between runs, so this is fabric contention rather than bad placement. Practical consequence: measure any transport change with at least three cold starts, because a single run can be 1.31x off, which is larger than a plausible incremental win and can either hide a real improvement or manufacture a fake one.

What is not a performance claim

The Q=64/KV=2 TP8 -> TP8 arm described above is a correctness arm and its timings are meaningless: the model is deliberately reduced to four layers, and each TP8 group spans two 4-GPU nodes, so the transport crosses the fabric for a geometry no production run would choose. Its valid result is only: after vLLM loaded the same checkpoint, refit changed 0 of 34 parameters on all 8 receiver ranks. The same timing exclusion applies to the 12-step correctness arms, which use 512-token generation to make the gates non-vacuous rather than to benchmark latency.

Correctness gates

These are from the Qwen3-30B-A3B MoE topology, not from the dense timing arm or the reduced KV<TP model: Megatron TP2/EP4/DP2 on 16 GPUs -> four vLLM TP4 replicas on 16 GPUs. The correctness recipe runs 12 refits with 512 generated tokens and 4 prompts x 4 generations so rewards differ within a GRPO group and weights actually move. It is intentionally longer and more generation-heavy than the performance recipe.

Thresholds enforced by the functional gate, with what that 12-refit loop measured against merged ModelExpress main:

Gate Threshold Measured
token_mult_prob_error < 1.05 1.0172
js_divergence_error < 1e-3 3.562e-4
grad_norm > 0 0.975568
coverage 100% 100.0% on every record
fallback / unsupported 0 0 / 0

Two deliberate choices worth understanding before changing them:

  • The gate asserts js_divergence_error, not an absolute gen_kl_error ceiling. Megatron and vLLM kernels have a non-zero baseline KL even when every parameter is bit-identical; measured with a refit that provably changed no parameter, that floor is 8.7e-4 to 1.3e-3 on Qwen3-30B-A3B, so a gen_kl_error < 1e-3 gate sits below its own noise floor and would fail a perfect refit. js_divergence_error is bounded and symmetric, and a single 1e-3 bound holds at every scale measured: 1.6e-4 on 0.6B, 1.3e-4 on 4B dense, 5.0e-4 on 30B MoE.
  • The gate requires a non-zero gradient. Without it a recipe that produces no reward signal passes vacuously: the weights never change, so "the generator matches the trainer" is trivially true. This actually happened and invalidated three earlier readings, which is why the reward sample was also widened from 2x4 to 4x8.

Review guide

1. QKV/KV-heads-below-TP contract

Commit: 83c3d1324

Review first:

  • nemo_rl/distributed/mx_megatron_helpers.py
  • nemo_rl/models/policy/workers/megatron_policy_worker.py
  • nemo_rl/weight_sync/mx_reshard_weight_synchronizer.py
  • the matching helper, publisher, policy-worker, and synchronizer tests

Please check that layer-local config wins, local head fields are emitted only when both counts divide by TP, fused rows are validated, and only the obsolete num_query_groups % TP guard was relaxed.

2. Megatron publisher foundation

Commits: 9bcc3b8ea, c966932a0, 9446bbce4

Review parameter role classification, global expert IDs, Megatron-Bridge name order, TP/ETP ranges, and fail-closed handling of unknown or ambiguous fused layouts.

3. Weight-sync and vLLM lifecycle

Commit: 84f13c2f7, with cleanup in aa2827a29

Review:

  • mx_reshard_weight_synchronizer.py
  • mx_reshard_publisher.py
  • mx_vllm_reshard_receiver.py
  • mx_reshard_config.py
  • vLLM worker/config/backend wiring

The important ordering is publish all trainer ranks, establish receiver quorum, pull/install on all receiver ranks, then commit. Any failed rank prevents version commit. Shutdown releases receivers before publishers so NIXL registrations remain valid until reads finish.

4. Reliability and observability

Commits: b236bc35d, 930f66a36, eae031f60, c473aef94

These are the commits behind the performance section. b236bc35d splits discover_s from mx_update_s and is what exposed the quorum cost; eae031f60 narrows the per-step check to the version stamp and falls back cleanly against an older ModelExpress, so the two repos need not land together; 930f66a36 makes the reporting unable to fail the refit it measures; c473aef94 adds MX_REFIT_VERIFY.

Review cleanup after exceptions, telemetry isolation, separate quorum timing, and parameter verification in mx_refit_verify.py. Telemetry must never turn a successful refit into a failed one.

5. Functional gate and portable tools

Review tests/functional/grpo_mx_reshard_refit.sh, the portable recipe configs, summarize_refit_stages.py, and attribute_variance.py.

attribute_variance.py deliberately reports stages for the rank that was fleet-critical on each step rather than the median across ranks, because those disagree: on the dense arm the all-rank median of wire_fused_s moves 1.90x between two cold starts while the fleet-critical total moves only 1.03x. Only the critical rank is on the critical path, so attributing from all-rank medians points at a stage that is not the problem.

Private namespace/image-specific cluster manifests are intentionally not included.

Evidence and scope

Validated at 32 GPUs (16 Megatron trainer GPUs, 16 vLLM receiver GPUs) on BF16:

  • dense and MoE refits reach 100% coverage with zero fallback and zero unsupported;
  • same-checkpoint verification finds zero changed parameters on every receiver rank;
  • moving-model probability, JS-divergence, and non-zero-gradient gates pass;
  • repeated refits (11, then 12) and ordered worker teardown complete cleanly;
  • the performance rows above, measured with three cold starts per arm.

KV heads below TP is now proven end to end, not only unit-qualified. A reduced model with Q=64, KV=2, head_dim=128 at Megatron TP8 -> vLLM TP8 reproduced the source checkpoint bit for bit: 0 of 34 parameters changed on all 8 receiver ranks, with vLLM holding the real checkpoint before the first refit so the comparison is exact rather than statistical. Zero geometry errors, and the sparse publish pattern is visible in the per-rank tensor counts. The earlier unit and CUDA-tensor qualification (exact Q/K/V reconstruction, same-weight and changed-weight parity, projection-output equality) still holds underneath it.

This is still not full Nemotron Ultra qualification: the E2E arm is a reduced 4-layer model without EP64, so KV<TP combined with EP64 and the full Ultra architecture remain open.

Re-verified after ModelExpress #635 merged: the 12-refit MoE loop and the KV<TP exact-verify arm were both re-run against ModelExpress main at f4660d2, with every gate passing at values equal to or slightly better than the pre-merge results. That was not a formality — main also picked up four unrelated ModelExpress PRs and a refactor of the QKV alias builder itself.

FP8 installation on this path is a known gap, not a silent one: 4bbaf6a54 keeps the arm as a reproducer. The transport completes cleanly and the install then raises "Cannot copy out of meta tensor" inside ModelExpress's quantized commit path, which is a ModelExpress-side bug.

Test plan

  • NeMo helper/publisher suites — 45 passed
  • NeMo mx_reshard synchronizer suite — 14 passed
  • policy-worker resolver wiring check
  • All mx_reshard suites re-run against merged ModelExpress main — 78 passed
  • ModelExpress companion reshard suites on main — 503 passed
  • Real 32-GPU BF16 dense A/B and MoE performance validation, three cold starts per arm
  • Real reduced Megatron TP8 -> vLLM TP8 Q=64/KV=2 E2E, bit-exact on all 8 receiver ranks
  • 12-refit MoE loop re-verified against merged ModelExpress main (f4660d2)
  • CI on head e45786afd
  • FP8 install on this path (blocked on the ModelExpress meta-tensor bug above)
  • Full Nemotron Ultra qualification, including KV<TP with EP64

@copy-pr-bot

copy-pr-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@KavinKrishnan
KavinKrishnan force-pushed the kavink/upstream-mx-megatron-publisher branch from feacdf7 to 537fa3a Compare August 13, 2026 21:32
@KavinKrishnan KavinKrishnan changed the title feat(refit): add Megatron publisher adapter for ModelExpress feat(refit): add ModelExpress Megatron-to-vLLM reshard path Aug 18, 2026
@KavinKrishnan
KavinKrishnan force-pushed the kavink/upstream-mx-megatron-publisher branch from 9c52bb6 to 6a49789 Compare August 18, 2026 19:15
@KavinKrishnan
KavinKrishnan marked this pull request as ready for review August 18, 2026 20:07
@KavinKrishnan
KavinKrishnan requested review from a team as code owners August 18, 2026 20:07
@svcnvidia-nemo-ci svcnvidia-nemo-ci added the waiting-on-maintainers Waiting on maintainers to respond label Aug 18, 2026
Add the pure Megatron role and shard-geometry helpers needed to describe TP, ETP, and grouped-expert ownership without gathering model weights.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Map native Megatron names and shard geometry to ModelExpress HF aliases, including global expert IDs and fail-closed fused gate/up ordering. Cover the full Qwen3-30B EP8 name set in CPU-only tests.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Remove obsolete v2 and EAGLE-specific material from the extracted helper, document the current ModelExpress seam, and skip integration-only tests when the optional dependency is absent.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Wire ModelExpress's reshard refit path into GRPO as a `refit_transport`
option alongside `nccl_reshard`. The trainer publishes a Megatron reshard
view with HF-named aliases; each vLLM receiver then pulls exactly the
slices its own TP rank needs over NIXL/UCX RDMA, rather than having the
trainer gather and broadcast full tensors.

Publish and receive are strictly sequential and both sit on the critical
path, so `sync_weights` times them separately as
`prepare_for_generation/mx_reshard_{publish,receive}`. Only the receive
half emits MX_REFIT_STAGE records, so without the split the publish cost
is invisible; on Qwen3-30B-A3B it is the larger of the two by roughly 3x.

Version ordering is deliberate: every trainer publishes version N before
any receiver begins pulling it, so a receiver can never observe a fleet
where some trainers still advertise N-1.

Validated on 32 GPUs (GB200, RoCE) against Qwen3-30B-A3B-Instruct
(TP2/EP4/ETP1 trainer, four TP4 vLLM replicas) and Qwen3-4B: 100%
coverage, 0 fallback, 11 consecutive refits, and `gen_kl_error` matching
the pre-existing transport on the dense model.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Move the training call into try/finally so cluster and generation
shutdown run even when training raises. Previously an exception skipped
teardown, leaving NIXL agents with registered memory and Ray actors
alive; the process then aborted during interpreter shutdown and buried
the original traceback under an unrelated fatal error.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The functional test gates on `max(train/token_mult_prob_error) < 1.05`,
matching the existing nccl_reshard test, so a refit that installs wrong
or stale weights fails rather than merely running.

The bench configs cover the GB200/RoCE topologies used to validate this
transport. Two environment settings there are load-bearing and easy to
lose:

- UCX_TLS excludes `ud` as well as `tcp`. With a live NCCL process group
  in the same process, destroying a NIXL agent aborts in ud_iface.c
  ("unable to remove iface timer handler"); rdmacm handles wireup, so
  dropping UD costs nothing measurable.
- NCCL_CROSS_NIC=0. Each node holds an address on all four RDMA rails,
  and NCCL will otherwise pair a local rail-0 HCA against a peer's
  rail-3 address. Those are different subnets, so RoCE never connects and
  the queue pair dies with IBV_WC_RETRY_EXC_ERR.

`megatron_cfg.checkpoint.async_save` is off: these recipes never write a
checkpoint, and the persistent async-checkpoint worker's
multiprocessing.Manager fork intermittently dies during worker init,
killing the run with an EOFError. It cost 2 of the first 5 MoE runs.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The refit's dominant cost was invisible. MX_REFIT_STAGE records cover only
the install, so MX telemetry reported 1.9 s of a 10.25 s refit and looked
healthy.

Emitting discover_s alongside mx_update_s shows the quorum check is
5.20 s, or 51% of the refit and 2.7x the actual weight transfer. It
re-fetches and re-parses 78760 tensor entries from 16 trainer sources on
every refit: one list_sources plus a get_metadata round-trip per rank,
each returning that rank's whole shard table. None of it changes between
steps; only publisher_step does.

The cost scales with source count rather than bytes, which is why a 30B
MoE with 18432 expert tensors exposed it and a dense 4B (~6400 entries,
~0.4 s) did not.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The phase split read payload.tensors directly, so any rendezvous payload
without a shard table raised AttributeError *after* the weights had
already installed, turning a successful refit into a failed one.
Reporting must not be able to break the operation it measures.

Missing shard tables now count as zero and the emit is guarded, with
regression tests for both the absent-field case and the counting itself.
Caught by the unit suite, which had never been run against these changes.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The receiver's per-step version check does not read shard geometry; MX
discovers that once in _prepare and keeps it. Requesting the tables
anyway rebuilt 78,760 entries across 16 ranks on every refit.

Falls back to the full fetch against an MX predating the flag, so the two
changes need not land together, and the entry count now comes from the
payload's own tally so the metric that exposed this cost does not read
zero once the tables are skipped.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
gen_kl_error is a k3 estimator, and the benchmark recipe feeds it at most
128 generated tokens per step, where one outlier token moves it a long
way. This raises max_new_tokens 8 -> 128 and changes nothing else, so
batch sizes and the refit path are untouched.

The result was negative and worth keeping: the estimate did not fall below
the 1e-3 guideline, it stabilized at ~1.5e-3 with the spread narrowing
from 5.5x to 2.6x. So the exceedance is real rather than sampling noise,
and the no-refit reference is still needed to attribute it. See doc 21.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Every other refit transport can check the weights it moved: SGLang has
check_weights(compare), the sparse transports emit delta_verify/*.
mx_reshard had only end-to-end logprob metrics, which conflate refit
fidelity with Megatron-vs-vLLM implementation divergence.

MX_REFIT_VERIFY=1 fingerprints every vLLM parameter before and after each
install and emits MX_REFIT_VERIFY naming what changed. Fingerprints are two
allocation-free int64 reductions over the raw bytes, so they catch a single
flipped mantissa bit, where a float statistic can miss one; retaining a
pre-refit copy would instead cost ~15 GB per rank. Off by default, since it
sits on the refit critical path, and it cannot fail the refit it verifies.

Running it found something that matters more than the feature: the
benchmark recipes never train. max_new_tokens=8 means no generation can be
correct, so every reward is 0, the leave-one-out baseline makes every
advantage 0, and Loss is 0.0000 at every step. Confirmed against lr=1e-2,
where nothing changed either.

So the first refit changes all 435 params and every later refit transports
byte-identical weights. Performance rows are unaffected, but from step 2 on
the correctness gates cannot tell a working refit from a no-op, because the
correct answer is "no change". See doc 22.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
grpo_math_1B_megatron.yaml has no refit_cfg subtree, so Hydra refused the
plain `=` overrides for refit_transport and mx_reshard.server_url and the gate
died during config parsing, before training. Switch both to `++`.

Earlier validation replayed archived TensorBoard data through check_metrics.py,
which exercised the assertion but never the launch. Running the gate live on
2 GPUs now reaches the end and passes at token_mult_prob_error 1.0154 < 1.05.

Also adds topoA_correctness.yaml, a correctness arm that generates 512 tokens
with 4 generations per prompt so rewards can differ within a group. The
performance recipes generate 8 tokens, score every sample 0, and never train,
which made every post-first refit gate vacuous.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The ratio check alone passes on a run that never trains. GRPO's leave-one-out
baseline zeroes the advantage of any prompt group whose rewards are all equal,
so if no group is mixed the second refit re-sends bit-identical weights and
`token_mult_prob_error < 1.05` is satisfied by a no-op (doc 22).

Assert `max(train/grad_norm) > 0` so the ratio check is only credited on a model
that moved, and widen the reward sample from 2x4 to 4x8 so a zero-gradient run
is rare rather than a coin flip. The previous shape solved 1 of 8 sequences, and
that single sequence produced the entire gradient.

Verified against the archived metrics of a live gate run: the assertion passes at
grad_norm 5.21, and a negative control with grad_norm zeroed fails the new check
while still passing the ratio check, which is the vacuity it is meant to catch.
The widened batch shape itself is not yet confirmed on a live run; cluster
capacity was reclaimed mid-validation.

Also adds a single-node smoke config that runs the gate twice, once BF16 and
once with a vLLM MXFP8 rollout, to exercise FP8 on this path for the first time.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
…lure

Two things learned by running it. vLLM FP8 generation asserts
use_importance_sampling_correction in grpo.py, so without that override the run
dies in setup before any refit. With it, the arm reaches the first refit, the
transport completes cleanly (898 descriptors, 0 full-pull sources), and the
install raises "Cannot copy out of meta tensor" inside ModelExpress's quantized
commit path. That is an MX-side bug, so the arm is kept as its reproducer rather
than removed.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Four steps proved weights move and the gate holds while they do, but cannot say
whether per-refit error accumulates, whether gen_kl_error keeps creeping past
~1.4e-3, or whether discover_s stays flat across many refits. Same 512-token,
4x4 shape as the 4-step arm, twelve steps.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
gen_kl_error mixes runtime divergence with refit error, and only the sum was ever
measured. This arm separates them by having vLLM hold the real checkpoint before
the first refit, so both sides provably hold identical weights.

Result: the first refit changes 0 of 435 parameters on all 16 receiver ranks, so
the refit is bit-exact and gen_kl_error at that point is pure Megatron-vs-vLLM
divergence: 8.7e-4 and 1.3e-3. The documented < 1e-3 gate therefore sits below
the model's own floor, and no refit can pass it. See doc 27.

The arm needs a diagnostic escape hatch for load_format, kept out of this branch
and archived alongside the evidence, because NeMo-RL forces load_format=dummy
whenever a refit transport is set.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The CI gate had no KL-family assertion, and the obvious candidate, an absolute
gen_kl_error < 1e-3, is not safe to add: that metric has a non-zero floor set by
Megatron-vs-vLLM kernel differences which grows with model size. Measured with a
refit that provably changed no parameter, the floor is 8.7e-4 to 1.3e-3 on
Qwen3-30B-A3B, so the bound is below the floor and unpassable there regardless of
refit correctness.

js_divergence_error is bounded and symmetric, and a single 1e-3 bound holds across
every scale measured: 1.6e-4 on 0.6B, 1.3e-4 on 4B dense, 5.0e-4 on 30B MoE.

Validated offline against both archived gate runs (pass at 1.5e-4 and 1.6e-4) and
against a negative control with the metric inflated to 1.1e-3 (fails). See doc 27.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
summarize_refit_stages.py answers how fast a run was; it cannot say why two
identical runs differ. This adds the other cut: per-stage spread across runs, and
whether the slowest rank is the same one each time, which separates placement from
contention.

It deliberately reports stages for the rank that was fleet-critical on each step
rather than the median across ranks, because those disagree. On the dense arm the
all-rank median of wire_fused_s moves 1.90x between two cold starts while the
fleet-critical total moves only 1.03x: the body of the distribution shifts but the
tail sets the refit duration. Only the critical rank is on the critical path, so
attributing from all-rank medians would point at a stage that is not the problem.

topoA_variance.yaml runs the MoE arm at the full 11 steps for doc 19 item 8.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Resolve QKV heads from each live Megatron layer so heterogeneous attention and
KV-heads-below-TP layouts reach ModelExpress without zero local KV counts. Keep
the root config only as a validated compatibility fallback.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Retain only portable recipes and analysis tools; namespace- and private-image-
specific validation manifests remain internal evidence rather than upstream API.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Keep Megatron generation on its native synchronizer while ensuring non-colocated
MX and NCCL reshard paths skip the legacy HF refit-info handshake.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The PR description promises portable recipes with no private cluster detail,
but three headers still carried a colleague's name, an internal design-doc
filename, internal cluster state, and a stale claim that fused grouped-expert
install is unimplemented. That claim is no longer true once ModelExpress NVIDIA-NeMo#635
lands its fused MoE capture fix, so a reader would have been told the dense
recipe works around a gap that no longer exists.

Keep the technical rationale each header carried -- geometry, why a dense arm
isolates non-expert resharding, why the KL sample recipe raises max_new_tokens
-- and drop the parts that only mean something inside the originating cluster.

Also replace the "NVIDIA-NeMo#496" ModelExpress PR reference in
canonicalize_grouped_expert_name with a description of the behaviour it
depends on, since that number resolves to nothing for a NeMo-RL reader.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The repo runs ruff and ruff-format in pre-commit, and these files were left
unformatted, so the hook would have failed on them. Reflow only: the changes
are line breaks, parentheses and trailing commas, with no token otherwise
altered.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
The quorum check asks MX for `with_tensors=False` to skip rebuilding a shard
table that is identical every step, and fell back to the full fetch when MX
predated the flag by catching TypeError. That also caught a TypeError raised
*inside* discovery, silently retrying it as the fetch it was trying to avoid,
so a genuine bug in MX would have surfaced as an unexplained per-step slowdown
rather than as an error.

Ask the signature instead, which is what the publish path already does for
`publish_registered_shard_table`, and cache the answer: the installed MX cannot
change under a live receiver.

The tests could not express this before, because a bare MagicMock reports only
*args/**kwargs and so matches neither contract. Autospec two stubs carrying the
real signatures, which additionally makes a call the installed MX would reject
fail in the test too, and cover the swallowed-TypeError regression directly.

Also drop the "NVIDIA-NeMo#635" references from this module: the PR number resolves to
nothing for a NeMo-RL reader, and the error message now names what the adapter
needs and where to look instead.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Five findings from a pass over the reshard sources.

`_bridge_module_type_registry` re-ran the Bridge import and copied the registry
once per parameter while classifying a publish set, which is thousands of times
per refit for a 30B model, to read a value that cannot change mid-process. Cache
it, and freeze the sets so a caller cannot mutate the cached copy.

`publish_megatron_hf_aliases` built every alias before rejecting a negative
`publisher_step`. Validate the argument first.

`published_byte_count` was exported and tested but never called, and
`MxMegatronPublisher.publish` discarded the table that
`publish_megatron_hf_aliases` documents itself as returning so a caller can
report counts without rebuilding it. Wire them together into a publish-side
phase record, mirroring the receiver's MX_RECV_PHASE: the synchronizer already
times this half, but elapsed time alone cannot distinguish a publish that
described more shards from one that described the same shards more slowly.
Reporting is guarded, because the bytes are already out by then.

`fingerprint_model` returns {} when a parameter cannot be fingerprinted, which
disables verification for that refit. It did so silently, so a verification tool
that had switched itself off reported the same thing as one that was passing.
Say so instead.

Finally, two comments that did not match their code: `UnmappedMegatronTensor`
promises the publish fails rather than skipping a tensor, while
`build_megatron_alias_inputs` does skip one -- reachable only through a
non-strict resolver, which is now stated -- and the version counter's
last-committed semantics are safe only because a failed refit aborts the run,
which anything adding a retry needs to know.

Signed-off-by: Kavin Krishnan <kavink@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community-request waiting-on-maintainers Waiting on maintainers to respond

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants